Skip to content

fix(driver-sql): canonical ISO-8601-Z for user NOW()-default temporal fields on SQLite#2346

Merged
os-zhuang merged 1 commit into
mainfrom
claude/nostalgic-solomon-8e0177
Jun 26, 2026
Merged

fix(driver-sql): canonical ISO-8601-Z for user NOW()-default temporal fields on SQLite#2346
os-zhuang merged 1 commit into
mainfrom
claude/nostalgic-solomon-8e0177

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

What

The ADR-0074 follow-up it explicitly scoped out: a user-declared Field.datetime (or date/time) with defaultValue: 'NOW()' got col.defaultTo(knex.fn.now()) in createColumn. On SQLite that compiles to CURRENT_TIMESTAMP — a timezone-naive 'YYYY-MM-DD HH:MM:SS' (no millis, no zone) that Date.parse reads as local time, so the stored UTC wall-clock shifts by the host offset on a non-UTC runtime. The same class of bug ADR-0074 fixed for created_at/updated_at.

Worse, the same datetime column mixed storage: an explicit JS Date is bound by better-sqlite3 as INTEGER epoch ms, while an omitted value took the naive TEXT default — one column holding both.

Fix (SQLite-only; Postgres/MySQL keep native now() and are unaffected)

  • WritenowColumnDefault(type) emits a per-type canonical default via strftime: datetime → ISO-8601-Z (2026-06-26T10:34:13.891Z), date → YYYY-MM-DD, time → HH:MM:SS.fff.
  • ReadformatOutput folds every Field.datetime storage form (INTEGER epoch ms, canonical ISO-Z, legacy naive TEXT → interpreted as UTC) to one canonical ISO-8601-Z instant via normalizeSqliteDatetimeOutput. Idempotent, total, repairs legacy rows on read with no data migration.

A DDL default only governs newly-created columns, so existing-table coverage comes from the read normalization (not app-side stamping like ADR-0074's audit fix) — an explicit Date stays INTEGER regardless, so the INTEGER-vs-TEXT split is inherent to SQLite and resolved at the read boundary. Decision rationale lives in the changeset + the nowColumnDefault comment (no new ADR).

Reconciliation with ADR-0074 (#2342, just merged)

Rebased onto ADR-0074. normalizeSqliteDatetimeOutput reuses its repairNaiveUtcAuditTimestamp for the string shapes (single source of the zone-naive→UTC rules) and adds the INTEGER epoch-ms / Date folding. Consequently a datetime-typed audit column now also presents as canonical ISO-Zmore consistent than before (ADR-0074's own string-typed audit columns already present as ISO-Z). ADR-0074's "datetime-typed created_at reads as epoch-ms number" test is updated to expect the canonical string.

Testing

  • @objectstack/driver-sql: 227 passed (incl. 10 new in sql-driver-user-datetime-default-format.test.ts; ADR-0074's reconciled test green).
  • @objectstack/service-analytics: 164 passed — analytics SQL bucketing reads these columns via strftime, bypassing formatOutput; verified ISO-Z TEXT buckets identically to the old naive TEXT, so no regression.

🤖 Generated with Claude Code

… fields on SQLite

A user-declared Field.datetime (or date/time) with defaultValue:'NOW()' took the
knex.fn.now() -> CURRENT_TIMESTAMP column default on SQLite, storing a
timezone-naive 'YYYY-MM-DD HH:MM:SS' that Date.parse reads as LOCAL time (the same
class of bug ADR-0074 fixed for the builtin audit columns, scoped out there for
user fields). Worse, the same column mixed storage: an explicit Date is bound by
better-sqlite3 as INTEGER epoch ms while an omitted value took the naive TEXT
default.

- createColumn's NOW() default now emits a per-type canonical via strftime
  (datetime ISO-8601-Z, date YYYY-MM-DD, time HH:MM:SS.fff) through the new
  nowColumnDefault helper.
- formatOutput folds every Field.datetime storage form (INTEGER epoch ms,
  canonical ISO-Z, legacy naive TEXT) to one canonical ISO-8601-Z instant
  (normalizeSqliteDatetimeOutput), repairing legacy rows on read with no data
  migration. SQLite-only; Postgres/MySQL keep native now() and are unaffected.

Follow-up to ADR-0074 (no new ADR), rebased onto it. normalizeSqliteDatetimeOutput
reuses ADR-0074's repairNaiveUtcAuditTimestamp for the string shapes and adds the
INTEGER epoch-ms / Date folding, so a datetime-typed audit column now also presents
as canonical ISO-Z (consistent with every datetime field) — ADR-0074's
"datetime-typed created_at reads as epoch-ms number" test is updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 26, 2026 12:17pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jun 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/plugins.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/driver-sql)
  • content/docs/concepts/terminology.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/guides/driver-configuration.mdx (via @objectstack/driver-sql)
  • content/docs/guides/packages.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 8a7e9f1 into main Jun 26, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/nostalgic-solomon-8e0177 branch June 26, 2026 12:21
os-zhuang added a commit that referenced this pull request Jun 26, 2026
…ad (SQLite) (#2347)

Field.time is a tz-naive time-of-day, not an instant (#2004). A
defaultValue:'NOW()' time column historically took the full SQLite
CURRENT_TIMESTAMP default, so a defaulted/legacy row read back a full
'YYYY-MM-DD HH:MM:SS' timestamp instead of a time-of-day.

formatOutput now repairs a Field.time value to just its time portion (toTimeOnly):
a legacy full timestamp (or a full ISO value that leaked into the column) is
sliced to HH:MM[:SS[.fff]], while a value already stored as a bare time-of-day is
left untouched. Deliberately NARROW + read-only (no write/filter counterpart) so
it introduces no write/read asymmetry and preserves exact round-trips for bare
time-of-day values (the field-zoo f_time guard). Runs for every dialect (a native
TIME column already returns a time-of-day → no-op). Adds a timeFields registry +
registration mirroring dateFields/datetimeFields.

Completes the temporal read normalization alongside #2346: datetime -> ISO-8601-Z,
date -> YYYY-MM-DD, time -> wall-clock time-of-day.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant